Skip to content

feat: follow key chains when resolving foreign key columns#166

Merged
zantvoort merged 4 commits into
mainfrom
feat/key-chain-flattening
Jul 3, 2026
Merged

feat: follow key chains when resolving foreign key columns#166
zantvoort merged 4 commits into
mainfrom
feat/key-chain-flattening

Conversation

@zantvoort

Copy link
Copy Markdown
Collaborator

Summary

A foreign key to an entity whose primary key is itself an entity reference resolved to a single <field>_id column instead of the referenced key's columns, breaking every operation on such entities (dependent one-to-one chains). Key structure was derived independently — and inconsistently — by column naming, column typing, and value extraction.

Key chains as a first-class model concept. RecordReflection.getPkLeaves flattens a primary key into its terminal fields — one KeyLeaf per database column, each carrying the accessor path from the declaring table. Foreign keys that are primary keys recurse into the referenced entity's key, record keys contribute their components, and circular chains or missing keys fail fast at model construction with a clear message. Column naming (getForeignKeys), column emission (ModelFactory), and value extraction (ModelImpl) all consume the flattened leaves; the old one-level getNestedPkFields is gone.

Column.persistedType(). The Java type of the value as it is persisted to the column: the chain's terminal type for foreign keys, the converter parameter type for converter-backed columns, the field type otherwise. Dialects no longer need any knowledge of key structure — H2's MERGE cast resolution reduced from ~40 lines with FK special-casing to a type lookup.

Foreign key columns are now schema-validated. Validation previously checked FK columns against the declared entity type, which the compatibility map cannot know — the check was silently skipped. Checking persistedType() validates the referenced key's terminal type against the database column, through chains.

Performance. All flattening happens once at model construction; ModelImpl precomputes per-column accessor paths so per-row extraction is a plain loop of MethodHandle-backed invocations, replacing the per-row instanceof cascade. RecordMapper's early-cache PK shortcut now only constructs the key directly when the key record is flat (constructor arity equals flat column width); chained keys build through the regular argument plan.

Docs. New "Key Chains" subsection under "Primary Key as Foreign Key" in relationships.md; matching bullet in the storm-entity-kotlin/storm-entity-java skills.

Verification

  • Full reactor: 6,680 tests, 0 failures/errors/skips, including the PostgreSQL, Oracle, SQL Server, MySQL, and MariaDB container suites.
  • New H2 coverage: full CRUD with graph hydration and MERGE upserts on a two-level compound key chain, a single-column two-level chain, and fail-fast on circular key chains.
  • RecordReflection unit tests migrated to getPkLeaves, including the flipped expectation: FK-typed primary keys now flatten into the referenced key instead of stopping at the field.

zantvoort added 4 commits July 3, 2026 22:04
A foreign key to an entity whose primary key is itself an entity
reference resolved to a single <field>_id column instead of the
referenced key's columns, breaking every operation on such entities.
The key structure was derived independently — and inconsistently — by
column naming, column typing and value extraction.

The key chain is now reified in one place: RecordReflection.getPkLeaves
flattens a primary key into its terminal fields, one per database
column, each carrying the accessor path from the declaring table.
Foreign keys that are primary keys recurse into the referenced entity's
key, record keys contribute their components, and circular chains or
missing keys fail fast at model construction with a clear message.

Column naming, column emission and value extraction all consume the
flattened leaves. Column gains persistedType() — the Java type of the
value as it is persisted to the column (the chain's terminal type for
foreign keys, the converter parameter type for converter-backed
columns) — so dialects no longer need any knowledge of key structure:
the H2 MERGE cast resolution reduces to a type lookup. ModelImpl
precomputes per-column accessor paths at model construction, so per-row
extraction is a plain loop of accessor invocations. RecordMapper's
early cache lookup now only constructs the key directly when the key
record is flat; chained keys are built through the regular argument
plan.

New H2 coverage: full CRUD and MERGE upserts on a two-level compound
key chain, a single-column two-level chain, and fail-fast on circular
key chains.
Schema validation checked foreign key columns against the declared
entity type, which the type compatibility map does not know — the check
was silently skipped. Validating against the persisted type checks the
referenced key's terminal type against the database column, following
key chains.
@zantvoort zantvoort added this to the 1.11.7 milestone Jul 3, 2026
@zantvoort zantvoort merged commit b7676df into main Jul 3, 2026
7 checks passed
@zantvoort zantvoort deleted the feat/key-chain-flattening branch July 3, 2026 20:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant